Skip to content

erts: improve ordered_set AVL and CA tree memory layout - #10880

Open
NelsonVides wants to merge 4 commits into
erlang:masterfrom
NelsonVides:ets/ordered_set/struct_layouts
Open

erts: improve ordered_set AVL and CA tree memory layout#10880
NelsonVides wants to merge 4 commits into
erlang:masterfrom
NelsonVides:ets/ordered_set/struct_layouts

Conversation

@NelsonVides

Copy link
Copy Markdown
Contributor

This change is divided in three commits with big descriptions, where in general the goal is to improve CPU cache lines by shuffling and padding how some structs for ordered sets are laid out. See commit messages for details.

Some benchmark results using infrastructure from ets_SUITE on an AMD Ryzen 9 9950X3D (16cores/32threads)

master
#BENCHMARK STARTED$
Scenario:  50.000000% insert,  50.000000% delete | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 2794774.400000; 2077332.800000; 931881.000000; 408071.800000; 372202.600000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 2371785.800000; 17107236.200000; 23498497.600000; 29664773.200000; 35549817.000000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 2347569.800000; 17124393.400000; 23313604.000000; 30001014.200000; 35808308.000000$
Scenario:  10.000000% insert,  10.000000% delete,  80.000000% lookup | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 2726063.000000; 2831302.400000; 1160679.200000; 970555.000000; 815178.800000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 2630458.600000; 19993984.600000; 32603888.000000; 41826772.200000; 46652148.600000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 2311949.400000; 15778949.600000; 24305633.600000; 31639692.600000; 37018277.000000$
Scenario:  1.000000% insert,  1.000000% delete,  98.000000% lookup | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 2964548.600000; 9077014.200000; 3696778.800000; 2896464.000000; 2396620.600000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 2981088.000000; 20903995.600000; 38369291.400000; 48197186.400000; 56463480.000000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 2579171.800000; 18262131.200000; 30934530.200000; 39162314.800000; 47948371.800000$
Scenario:  10.000000% insert,  10.000000% delete,  40.000000% lookup,  40.000000% nextseq100 | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 260315.000000; 362188.000000; 203344.200000; 154373.600000; 134054.400000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 176224.200000; 1148005.200000; 1786273.000000; 2441205.200000; 2922286.400000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 132544.400000; 1063822.800000; 1960668.200000; 2563167.400000; 2955199.600000$
Scenario:  10.000000% insert,  10.000000% delete,  79.000000% lookup,  1.000000% selectAll | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 4575.800000; 35733.000000; 29338.800000; 68965.800000; 70469.000000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 4721.400000; 39504.200000; 91007.000000; 114184.600000; 124621.600000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 4385.200000; 39293.000000; 90449.200000; 108863.400000; 119517.800000$
Scenario:  10.000000% insert,  10.000000% delete,  79.000000% lookup,  1.000000% partial_select1000 | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 3494.600000; 30085.000000; 46033.400000; 51939.000000; 50799.200000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 3329.800000; 32481.800000; 66579.600000; 79703.400000; 86538.000000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 2976.400000; 31318.200000; 67547.600000; 77956.400000; 84478.400000$
Scenario:  99.900000% lookup,  0.100000% insert | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 3141352.000000; 13851793.400000; 10440908.800000; 11080099.000000; 10764093.400000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 3003912.200000; 20946235.400000; 30020701.200000; 39714828.000000; 47795486.600000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 2710375.600000; 20309331.600000; 35720024.400000; 43988302.000000; 51977661.400000$

#BENCHMARK ENDED$
this branch
#BENCHMARK STARTED$
Scenario:  50.000000% insert,  50.000000% delete | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 2735086.200000; 2037637.600000; 954431.200000; 419877.400000; 371226.600000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 2317657.400000; 17102914.800000; 24088957.200000; 31056502.400000; 36864752.400000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 2310192.000000; 16907911.800000; 24358384.000000; 31659954.800000; 37475269.000000$
Scenario:  10.000000% insert,  10.000000% delete,  80.000000% lookup | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 2574937.200000; 2778787.400000; 1127751.600000; 1128851.000000; 932251.000000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 2515600.200000; 18817115.000000; 32463820.400000; 41582716.400000; 49203867.800000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 2257846.800000; 15513853.000000; 24529160.400000; 32607766.600000; 38879761.800000$
Scenario:  1.000000% insert,  1.000000% delete,  98.000000% lookup | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 2834927.000000; 9134101.600000; 3605719.000000; 3246456.200000; 2368807.800000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 2802775.800000; 19991268.400000; 37195447.000000; 45866034.600000; 52715669.800000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 2544461.000000; 18076990.000000; 32305961.000000; 40507541.800000; 47557546.600000$
Scenario:  10.000000% insert,  10.000000% delete,  40.000000% lookup,  40.000000% nextseq100 | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 254980.800000; 295764.600000; 166729.600000; 156036.400000; 134930.200000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 177402.800000; 1182453.000000; 1844537.400000; 2480923.000000; 2939509.000000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 139710.600000; 1044782.400000; 1985030.800000; 2564749.600000; 3007024.600000$
Scenario:  10.000000% insert,  10.000000% delete,  79.000000% lookup,  1.000000% selectAll | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 4329.400000; 23713.000000; 28143.200000; 32651.200000; 33575.200000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 4505.000000; 40095.000000; 91988.200000; 115471.600000; 121441.400000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 4482.400000; 38866.800000; 89456.200000; 110851.400000; 122724.400000$
Scenario:  10.000000% insert,  10.000000% delete,  79.000000% lookup,  1.000000% partial_select1000 | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 3673.600000; 21950.600000; 22085.000000; 51286.200000; 24177.600000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 3198.800000; 33157.400000; 68219.000000; 78909.000000; 87430.000000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 3489.600000; 32098.800000; 67413.600000; 81416.800000; 88637.200000$
Scenario:  99.900000% lookup,  0.100000% insert | Key Range Size: 1000000$
; 1; 8; 16; 24; 32$
,[ordered_set,public,{write_concurrency,false},{read_concurrency,true}] ; 2913542.400000; 19830882.000000; 21730708.200000; 20753824.400000; 18156548.000000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,false}] ; 2916702.600000; 20817294.600000; 32790746.800000; 42417380.600000; 49989099.800000$
,[ordered_set,public,{write_concurrency,auto},{read_concurrency,true}] ; 2630691.400000; 19743146.800000; 36104860.400000; 45511777.000000; 52950479.400000$

#BENCHMARK ENDED$

Results are ranging from 0-3% improvements depending on the scenario, at least for how biased the benchmark might actually be. I've ran these benchmarks a dozen times on both master and this branch and have gotten widely varying data for all cases, so not sure how to benchmark into statistically sound results, but at least theoretically the changes are sound (or at least I had a great experience researching them :D)

@github-actions

github-actions Bot commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

CT Test Results

    3 files    136 suites   50m 56s ⏱️
1 678 tests 1 621 ✅ 57 💤 0 ❌
2 320 runs  2 245 ✅ 75 💤 0 ❌

Results for commit bd03ca6.

♻️ This comment has been updated with latest results.

To speed up review, make sure that you have read Contributing to Erlang/OTP and that all checks pass.

See the TESTING and DEVELOPMENT HowTo guides for details about how to run test locally.

Artifacts

// Erlang/OTP Github Action Bot

@NelsonVides
NelsonVides force-pushed the ets/ordered_set/struct_layouts branch 2 times, most recently from ab8d64c to f04e46b Compare March 19, 2026 11:50
@rickard-green rickard-green added the team:VM Assigned to OTP team VM label Mar 23, 2026

@sverker sverker left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks interesting.
However, I will not merge to master now for OTP 29.0 as we are quite late in the release schedule for such a large internal reworking.

Poke us if the PR hasn't been labeled with "testing" when summer comes after OTP 29.0 been released.

Comment thread erts/emulator/beam/erl_db_tree.h Outdated
Comment on lines +47 to +49
#define TREE_GET_BALANCE(p) \
((int)((UWord)(p)->left & TREE_TAG_MASK) | \
-((int)(((UWord)(p)->left & 2) >> 1) << 1))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be simplified to something like this, I think
(((int)left + 1) & 3) - 1

@bjorng bjorng added this to the 30.0 milestone Mar 31, 2026
@NelsonVides

Copy link
Copy Markdown
Contributor Author

@sverker hey there 👋🏽

As mentioned, pinging here, maybe we can roll this one into testing? 🙂

@sverker sverker added enhancement testing currently being tested, tag is used by OTP internal CI labels Jun 11, 2026
@sverker
sverker requested a review from jhogberg June 15, 2026 11:19
@sverker

sverker commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

The commit with AVL balance in pointer is making the code quite messy. We want the other two commits only in this PR.

Encode the AVL balance factor in the two low bits of the TreeDbTerm left
pointer instead of a separate field, reducing memory per node on 64-bit
(one word saved per node). Requires 8-byte-aligned nodes
(ERTS_ALLOC_ALIGN_BYTES >= 8).

The linux kernel implements a similar trick for its Red-Black trees,
storing the bits for the colour at the lowest bits of the pointer to the
parent.
The CA tree (contention-adapting tree) backs ETS ordered_set tables.
Profiling the struct layout on 64-byte cache lines reveals two
performance problems that this commit fixes by reordering struct fields
— no code changes, no memory overhead.

Problem 1: Base node false sharing

In the old layout, lock_statistics (written by contending threads
WITHOUT the lock held) sits on the same cache line (CL1) as lock tail
fields and is_valid (read/written by the lock holder). When a contender
bumps lock_statistics, the hardware invalidates the entire 64-byte cache
line on the lock holder's core, forcing a re-fetch of unrelated lock
internals.

Fix: Move root and is_valid up (CL1, co-located with lock tail — all
accessed under the lock, no conflict). Move lock_statistics down to CL2,
isolated with only the cold free_item field. Contenders now dirty a
cache line the lock holder never touches.

  Before CL1: [lock.q | lock.type | lock.rq_end | lock.tdata | lock_statistics | is_valid]
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^   ^^^^^^^^^^^^^
                lock holder reads/writes these                     contenders write this
                                                                   → FALSE SHARING

  After  CL1: [lock.q | lock.type | lock.rq_end | lock.tdata | root | is_valid]
                all accessed under the lock — no cross-core conflict
  After  CL2: [lock_statistics | free_item]
                contenders write here — isolated from lock holder

Problem 2: Route node poor cache locality

During lock-free traversal (find_base_node), each route node requires
reading: is_base_node (CL0), key.term (CL2), then left or right (CL1) —
3 cache-line fetches per node. For a tree of depth D, that is 3D
cache-line loads to reach a base node.

Fix: Move left and right to the front of the route node struct, placing
them on CL0 alongside is_base_node. The traversal loop now reads
is_base_node (CL0), key.term (CL2), then left/right (CL0, already
cached) — 2 cache-line fetches per node, a 33% reduction in cache misses
during traversal.

  Before: is_base_node[CL0]  →  key.term[CL2]  →  left/right[CL1]  = 3 CLs
  After:  is_base_node[CL0]  →  key.term[CL2]  →  left/right[CL0]  = 2 CLs

Both changes are pure field reordering in erl_db_catree.h. All field
accesses in erl_db_catree.c use named fields (->u.base.root,
->u.route.left, etc.), and allocation size macros use offsetof(), so
they auto-adjust. No .c file changes required.
Lift is_valid into the DbTableCATreeNode wrapper (shared by both
variants), saving 8 bytes per route node and moving key.term from
cache line 2 to cache line 1.

Align lock_statistics to the cache line (ERTS_CACHE_LINE_SIZE) so it
sits on its own line and avoids false sharing with the lock and root
when contending threads update it without holding the lock.
@NelsonVides
NelsonVides force-pushed the ets/ordered_set/struct_layouts branch from f04e46b to bd03ca6 Compare June 16, 2026 12:03
@NelsonVides

Copy link
Copy Markdown
Contributor Author

Hello there! I've rebased on top of the latest master, and undid that commit by git-revert, so that the original intent is not lost, in case we want to reconsider it later: I found it a very good memory saving one in case it's desired.

Let me know how your tests run, if you found any performance regression or improvements :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement team:VM Assigned to OTP team VM testing currently being tested, tag is used by OTP internal CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants